使用清理器替换终结器
Roger Riggs 发表于 2022 年 5 月 25 日
JDK 9 中引入了清理器,作为一种机制,用于为包含安全敏感内容或封装非堆资源的堆对象调用清理函数。JEP 421:弃用终结器以进行移除描述了替换终结器的理由以及开发人员的替代方案。随着 JEP 421 的推进,采用清理器作为终结器的便捷替代方案越来越受到开发人员的关注。这些示例展示了几种使用清理函数替换终结器的方法。
以前,清理工作是在类的 finalize
方法中完成的。即使对象不可达,当调用 finalize
方法时,它也可以访问对象的所有字段并对其进行更改。
与终结器一样,当发现对象无法从任何类或线程访问时,将运行清理函数。与终结器不同,清理函数将清理所需的状态与对象分开保存,因为我们希望对象在不可达时立即被回收。清理函数必须能够独立于对象工作。如果从清理函数中存在对该对象的任何引用,则该对象仍然是可达的,并且无法被回收。清理所需的任何状态都必须封装在清理函数中。
我将使用斜体来指代实例,包括对象 O、清理函数 F、清理器 C 和可清理对象 A,以帮助跟踪每个部分的内容。
对象 O 和相应的清理函数 F 在清理器中注册。注册通常在对象 O 的构造函数中完成。清理器返回一个 Cleanable
,其中包含对对象 O 及其清理函数 F 的引用。调用 Cleanable.clean()
最多运行一次清理函数。清理器有一个线程,该线程等待已注册的对象变得不可达,然后运行相应的清理函数 F。清理器及其线程相当重量级,应尽可能在应用程序、包或类中共享。
由于对象 O、清理函数 F 和清理器之间存在密切关系,因此需要注意几个潜在的编码陷阱。一个具体的例子说明了如何编写清理函数,以便在垃圾收集器回收对象时利用它。
类 SensitiveData
保存的数据应在不再使用时被擦除。通过清除内部字符数组,在清理函数(最初使用 lambda)中擦除数据。它由 close
方法或当其实例变得不可达时由清理器调用。SensitiveData
实现了 AutoCloseable,以鼓励在try-with-resources 中使用它,从而促进尽早进行清理的想法。
在非结构化上下文中,如果try-with-resources 不适用,则应直接调用 close
方法。当不再引用 SensitiveData
对象并且 close
方法尚未调用清理时,清理器将作为后备方案。
在本例中,我们将展示使用 lambda 或类 SensitiveCleanable
实现清理函数。清理所需的所有状态都封装在清理函数中。
import java.lang.ref.Cleaner;
import java.util.Arrays;
import java.util.Optional;
public class SensitiveData implements AutoCloseable {
// A cleaner
private static final Cleaner cleaner = Cleaner.create();
// The sensitive data
private char[] sensitiveData;
// The result of registering with the cleaner
private final Cleaner.Cleanable cleanable;
/**
* Construct an object to hold sensitive data.
*/
public SensitiveData(char[] sensitiveData) {
final char[] chars = sensitiveData.clone();
final Runnable F // Pick one
= () -> Arrays.fill(chars, (char) 0);// A lambda
// = new SensitiveCleanable(chars); // A record
// = clearChars(chars); // A static lambda
this.sensitiveData = chars;
this.cleanable = cleaner.register(this, F);
}
/**
* Return an Optional of a copy of the char array.
*/
public Optional<char[]> sensitiveData() {
return Optional.ofNullable(sensitiveData == null
? null : sensitiveData.clone());
}
/**
* Close and cleanup the sensitive data storage.
*/
public void close() {
sensitiveData = null; // Data not available after close
cleanable.clean();
}
/*
* Return a lambda to do the clearing.
*/
private static Runnable clearChars(char[] chars) {
return () -> Arrays.fill(chars, (char)0);
}
/*
* Nested record class to perform the cleanup of an array.
*/
private record SensitiveCleanable(char[] sensitiveData)
implements Runnable {
public void run() {
Arrays.fill(sensitiveData, (char)0);
}
}
}
一个简短的示例,展示了如何在try-with-resources 中使用 SensitiveData
以及如何清除临时数组,以最大程度地减少敏感数据在进程内存中可见的时间。
public class Main {
// Encapsulate a string for printing
public static void main(String[] args) {
for (String s : args) {
char[] chars = s.toCharArray();
try (var sd = new SensitiveData(chars)) {
Arrays.fill(chars, (char) 0);
print(sd);
}
}
}
// Print the sensitive data and clear
private static void print(SensitiveData sd) {
char[] chars = sd.sensitiveData().get();
System.out.println(chars);
Arrays.fill(chars, (char) 0);
}
}
编写清理函数
让我们仔细看看各种清理函数编码选择的优缺点。这些方法中的每一个都公开了一个没有参数的 FunctionalInterface 方法,该方法仅使用其自身持有的值执行清理。
lambda 清理函数
class Foo {
private final char[] data;
Foo(char[] chars) {
final char[] array = chars.clone();
cleaner.register(this,
() -> Arrays.fill(array, (char)0));
this.data = array;
}
}
对于简单的清理,lambda 简洁明了,并且可以在构造函数中内联编码。它易于编码,但可能难以发现错误并验证其是否按预期工作。例如,如果 chars
是一个字段,则 lambda 可以引用 this.chars
,从而无意中捕获 this
。除非编写了测试来检查它是否已清除,否则您可能不会注意到该对象未被收集并且未进行清理。
确保不捕获 this 的一种方法是在静态方法中创建 lambda。它的作用域没有 this,因此它不会被意外捕获。
private static Runnable clearChars(char[] chars) {
return () -> Arrays.fill(chars, (char)0);
}
作为记录、嵌套或顶级类的清理函数
class Foo {
// Record class to clear an array.
private record Cleanup(char[] array)
implements Runnable {
public void run() {
Arrays.fill(array, (char) 0);
}
}
private char[] data;
// Copy the array and register the cleanup.
Foo(char[] chars) {
final char[] array = chars.clone();
cleaner.register(this, new Cleanup(array));
this.data = array;
}
}
记录类、嵌套静态类或顶级类是编写成功的清理函数最可靠的方法。它可以很好地了解与对象无关的状态分离,并提供了一个记录清理的地方。
不要试图使清理函数成为内部类,它隐式地引用了 this
,并且不会运行清理。
使用可变状态进行清理
尽管在大多数情况下,记录或嵌套类字段是不可变的,但在某些用例中,清理所需的状态在对象 O 的正常使用期间会发生变化,因此需要是可变的。将可变状态保存在对象 O 和清理函数 F 都可以访问的对象中是处理这种情况的直接方法。如果多个线程正在修改状态,则它们将需要某种同步。当在不同用途之间共享清理器,并且需要对状态进行同步时,清理函数可能会阻塞清理器线程。与任何同步一样,请仔细检查同步是否会导致死锁或清理延迟。例如,如果清理涉及关闭网络连接,则最好创建一个独立于清理器线程的任务。
总结
最轻量级的清理函数可以是 lambda,但需要注意细节。lambda 不能隐式或显式地引用 this
。lambda 主体不能引用构造函数中的字段。这比通常允许使用 final 或实际上是 final 的值的限制更严格。
从终结器转换为清理函数可能需要一些重构才能分离状态。重构可以改善类的结构和可靠性。
包含各种选项的完整示例可从 SensitiveData.java 获得。
最初发布在此处。